重构,还是重写?(2020版)
Joel Spolsky (软件随想录作者)曾经写过一篇著名的文章, Things You Should Never Do (1) ,他在文章中断言,你永远不应该从头开始重写一个代码库。他举了 Netscape 公司的例子,他们花了好几年的时间重写软件,最终公司在这个过程中死亡。一年前,我重读了那篇文章,但还是选择了从头开始重写我们的应用,对,全部重写。以下介绍为什么这么做,我们是如何成功的,以及一些关于你是否也应该这么做的启发式分析。
故事要从 2019 年 1 月说起。当时,Remesh 还是一家比现在规模小得多的公司。当时招聘了一些工程师,有 5 名工程师专注于产品开发,还有一小部分工程师负责机器学习(ML)或 DevOps。尽管有这些工程师,但开发速度非常缓慢,简单的功能需要很长时间才能完成,产品有很多已知的 bug 没有修复,而且整个产品看起来很长时间没有明显变化。
了解为什么会有这些问题是很重要。假设问题不在人上面,我们有优秀的工程师(之后新版的成功也验证了这一点)。问题主要出在代码库和流程上。我们所使用的历史代码库,与团队的技能和业务场景并不匹配,当时的流程也鼓励和依赖工程师垂直领域的知识,也没有 "全栈"工程师。
2019 年 1 月代码库的状态
旧版应用的设计初衷与现在的版本截然不同。最初,Remesh 让用户在整个群之间或者一个人和一个群之间进行双向对话。例如,你可以让 Democrats 和 Republicans 各自对话,互相了解对方,寻找共同点。或者,你可以让一个城镇的市长和他们的市民对话,以更好地了解他们需要什么、相信什么、想要什么。然而,当我们找到了产品与市场的契合度后,用例也发生了变化。我们倾向于由一个单一的主持人与一群人交谈。
需求变化的结果是,某些旧的设计方案不再有意义,schema 需要进行重大改变。除了数据库之外,代码库本身也很难理解,因为这些功能都是在没来得及进行大的重构的情况下,被开发人员用螺栓连接起来的。在最需要重构的地方,测试覆盖率很差,因为这些代码是最老的代码,是在建立良好的测试实践之前编写的。
除此之外,语言和框架也不适合我们的团队。后端代码库是用一种叫 Elixir 的语言开发,而开发人员很少有人熟悉 Elixir。其中一个前台代码库是用非常老旧版 Angular(我甚至不想去了解到底是哪个版本,往事不堪回首),我们还有两个前台是用 React 写的。但工程师几乎没人了解其中一项技术,更不用说这三个都会。使用的语言和框架并不适合团队和我们的场景,这让开发速度非常慢。
有哪些选择?
毋庸置疑,我们的代码库需要一个重大的改变。当你面前摆着一堆代码,很难往前推进时,大概有三个选择:
重构它,直到所有问题修复。
一口气全部重写
逐步小范围重写
对于前端,重构并不是一个合适的选择,Angular 版本已经太老旧,以至于没有任何明确的升级路径可以升级到现代版本的 Angular(老实说,任何版本的 Angular 都兴趣不大)。而且由于预计 UI 和 API 会有重大变化,所以重构是不可行的。因此,在前端,我们只能选择一次性重写,或者逐步小范围重写。
后端有一些需要解决的问题 — 当前的模式、语言和代码库都不适合我们的场景。我们使用了 Elixir,因为它有强大的并发支持,但我们最终不太需要这个功能,而且它反而陷住了我们:Erlang 虚拟机中处理并发的方式使得代码分析变得非常困难,你知道计算的是什么,但不知道从哪里调过来的 — 祝你在性能调整方面好运。
Elixir 的代码库也限制了机器学习工程师对后端代码库的贡献:他们每天都在 Python 中工作,没有时间深入学习 Elixir。长话短说,我们想放弃 Elixir,转而使用 Python 语言,因为这样一来,整个团队就可以参与贡献后端代码,这门语言可以解决我们的需求,而且分析代码更加方便。
我们也有一些 "产品债务",老版本向用户引入了一些新东西,他们接触之后也逐渐喜欢上了这些理念,但最终效果并不理想。它们是局部的极端。如果我们要跳出这个局部极限,做出更好的东西。我们必须要做一次大的改版,在这个过程中,较小的迭代可能会不断遇到用户的阻力。去掉之前这些功能,需要同时做很多事情。
归根结底,重写的理由其实归结为以下几个因素:
希望团队的每个成员都能为后端代码库做出贡献,而 Python 既容易学习,又能在团队中得到广泛的认可,所以很适合我们。
旧代码库非常脆弱,测试量少,重构代码库是一个艰难的过程。
通过转移到像 Django 这样的强大的框架来提高效率,同时也能很多现成的东西节省时间(如 Django Admin)。
有机会根据从用户那里了解到的东西制作一个全新的版本,然后可以轻松升级到新的版本,而不是在每一次小改动花时间与客户解释,持续一个 12 个月的拉力战。这也使我们的客服团队和销售团队的培训在最后成为一次性的批量培训,而不是不断地引入新概念。
为了达成这个决定,我们做了相当广泛的规划。虽然整天谈论敏捷和精益什么的,但这次实际是一个瀑布式的开发 — 不是因为我们要实施瀑布式的计划,而是我们发现重写应用程序需要不少时间,但重构或零散地重写需要更长的时间,而且不确定性要高得多。如果走重构路线的话,我们要冒的风险会更大。
最后,我们对自己的决定很有信心,而且公司的各个层面都支持我们。我们决定重写,在让产品向前发展的同时,修复过去几年来的错误。
让重写开始吧。
进展情况
我们在 2019 年 2 月开始重写,在规划出功能范围之后,就开始启动重写,作为尽职工作的一部分,我们围绕着我们要开发的功能,制定了一个非常坚实的计划。这违背了敏捷的教条,但有了一个可以调整的计划,有助于指导我们前进的道路,看看是否偏离了轨道。当我们与用户(内部用户和一些外部客户)在进行测试的阶段,我们最终确实偏离了不少计划,更多的内容会在后面说。
在经历了一开始的坎坷之后,构建新版本的实际过程还算顺利。对于工程师来说,切换到一个新的技术栈是痛苦的。虽然我们选择了 Python 来达到最低的切入成本,但仍然有一些人需要学习。而且我们的后端工程师也没接触过 Django(但我们的首席前端工程师对 Django 有很深的了解)。同样,在前端方面,很多人都知道 React,但很少有人对 TypeScript 有深入的经验,我们选择 TypeScript 语言(这有一些故事要留待后文会说)。有了一些初步的学习时间后,我们都很快就有了相当大的收获。
这是我们第一个验证得到的经验:即使在这个新的技术栈中经验较少,也能更快地构建功能。要确定生产力的提高是来自于新的技术栈和新的代码库,而不是仅仅是一个空项目,这需要更长的时间,但我们最终还是达到了目标。
首先做的一件事就是让大家接触数据库。由于我们的目标之一是减少信息孤岛,让工程师尽可能了解整个技术栈,所以我们引导一些对数据库设计没有什么经验的前台开发人员,让他们去思考和设计最初的数据访问版本,然后和整个团队一起迭代。这使他们有能力去参与数据库方面的问题。尽管他们已经很久没有参与这方面工作,但仍然表现出了这方面能力,并能提出一些真正具有挑战性的问题。
在这之后,我们快速前行持续了几个月,重写了旧版本中熟悉的和感兴趣的东西,并在不断的优化,使其更加好用。我们在合理的时间内完成了一个非常好的项目。一开始,时间表非常乐观,直到 6 月左右,我们一直在按计划进行。不过后来增加和改变了一些功能,因为我们知道没有这些功能,新版本就不会成功。这让项目速度慢了下来,但来自内部研究人员、客服团队和一些值得信赖的用户的真实反馈,对我们项目成功是必要的。
在整个过程中,我们取得了一些我引以为傲的成绩,不全是技术方面的。
团队急剧增长。我们从最初的 4 个产品开发工程师开始,到现在的 9 个,这还不包括招聘了一个完整的 QA/SDET 团队,增加了机器学习工程团队的人员,以及招聘了 DevOps 工程师。而在这个急剧增长的过程中,并没有因为增加人员而带来通常的项目延迟 —— 相反,我们加快了速度(我认为这主要得益于这是一个全新项目)。
改善了整个公司对工程团队的看法。刚开始一段时间,我们在新功能的交付上有点慢,但至少可以快速地重写已有的功能,并看到新功能也很快地被添加。有一次,我们做了一个很酷的演示,对 Django 的 Admin 进行了实时编码,以证明现在可以做的事情比以前快很多。虽然只是一个小小的演示,但很有效。
从一个有多个服务的面向服务的架构,变成了一个只依赖一个服务的单体架构,我们从一开始就开始设计容错和横向可扩展性。这在之前是一个很大的痛点。
极大地提高了迭代速度,很大程度上是因为我们有了一个新的架构,这个架构适合我们的场景,而且是在一个大家(现在)都很乐意参与的技术栈中。锦上添花的是,机器学习团队现在可以也确实偶尔给生产后端提交代码。
主要经验
我们相信我们是成功的,当然过程中也犯了一些相当大的错误。
之所以成功,是因为我们一开始就对我们要打造的东西有一个清晰的愿景(一个真正的 MVP,我们知道旧产品是 "可行的",所以我们必须达到这个目标或更少),我们根据需要削减范围,以保持清晰的目标。虽然我们没有 "按时交付",但也没有变成 Netscape 的方式。项目总工期不到预计的两倍(基于完全复制旧产品功能的预期时间),但我们最终得到了一个更好的产品,并且有一些新的功能,比如上传和发送视频的能力,以及下载自动生成的 PowerPoint 报告等。
成功的另一个关键是尽早并经常获得反馈。在重写过程中,我们经常在内部使用产品,发现关键的 bug 和性能问题。我们还定期举行全公司的演示会,从帮助客户成功、销售、研究,以及从能够容忍各种问题的早期试用用户那里快速获得反馈。
做错的事情有哪些?我们曾经引入两个我们以前不怎么熟悉的技术。我们之前在一个原型中使用过 TypeScript,但我们对它没有很深的专业知识。进展虽然马马虎虎,但我们仍然不相信生产率会更高,缺陷率会更低;时间会证明,静态类型的语言会更佳(如果有人对此有确切的研究,我很乐意你把它们发给我)。
另一个失误是使用 GraphQL。我们在 REST 和 Redux 方面有相当高的经验,但之前只在一个原型中使用过 GraphQL。现在回想起来,GraphQL 让最初的原型开发速度快了很多,但长期的代价是,Apollo 中有些关键的设计决策我们并不认同(比如没有在前端暴露出检测订阅中断开/重连的能力),而且在其后端的性能调优经历也是一言难尽……那是我人生中非常艰难的一两个月,我再也不想回去了。我们现在正在从 GraphQL 中迁移出来,对于性能关键的东西,会快速地进行迁移,然后再慢慢迁移那些对请求性能容忍度较高的调用。
最后需要注意的是,在重写的时候,你的团队以及士气会受到影响,你必须要积极应对。一开始启动一个新项目是相当令人兴奋的,但接下来的事情就是构建已有的功能和修复 bug,过了一段时间就会觉得很累。很欣慰看到我的团队从构建我们已有的功能到开发新的功能,我也意识到重写工作真的很耗费精力。
我们成功地完成了重建,其中的一部分原因是平衡了新功能开发与旧代码迁移。话虽如此,我希望我们在平衡方面能做得更好。下一次,我将集中精力确保我们有一个早期的 alpha 测试计划,与几个值得信赖的用户一起进行测试,以获得定期的反馈和鼓励,并让大家对重建保持兴奋。我还会确保我们在早期就加入大量的新功能,而不是发现大家都有点疲惫,才开始引入新功能。有些单调是不可避免的,但你可以减轻它。
你应该这么做吗?
根据我的经验,你也许不应该像我这么做,如果你深信重写永远不会是正确的决定那些文章。无论如何,你应该默认为 "不重写" 的立场,然后非常努力地推进,并证明不重写是正确的。
但有几种情况,重写可能是合理的。
如果你的架构或模式与你的需求严重脱节,而且没有明确的迁移路径,渐进式更新架构或模式变得非常困难。
如果这些问题严重拖累了你的团队
如果你目前的技术栈限制了很多工程师的代码贡献,并且技术栈培训也不太可行。
即使所有这些都符合你的情况,你也要进一步考虑企业的实际情况,考虑到这对你的公司、你的团队是否有意义。
有可能在更多的情况下,重写是有道理的。辩解这一点很难,但它可能是值得走的一条路,而且可以成功地完成。
https://www.joelonsoftware.com/2000/04/06/things-you-should-never-do-part-i/
英文原文:
https://remesh.blog/refactor-vs-rewrite-7b260e80277a
长按二维码 关注「高可用架构」公众号